面向对象设计原则 (2)

思考并回答以下问题:

  • 什么是依赖倒转原则?和依赖注入和控制反转有什么不同?
  • 依赖倒置的“细节”可以理解为实现类。接口类和实现类是什么,是什么关系?
  • 什么是里氏替换原则?

单一职责原则

1
就一个类而言,应该仅有一个引起它变化的原因。

我们在做编程的时候,很自然地就会给一个类加各种各样的功能,比如我们写一个窗体应用程序,一般都会生成一个Form1这样的类,于是我们就把各种各样的代码,像某种商业运算的算法呀,像数据库访问的SQL语句呀什么的都写到这样的类当中,这就意味着,无论任何需求要来,你都需要更改这个窗体类,这其实是很糟糕的,维护麻烦,复用不可能,也缺乏灵活性。

方块游戏的设计

拿手机里的俄罗斯方块游戏为例。要是让你开发这个小游戏,你如何考虑?

首先它方块下落动画的原理是画四个小方块,擦掉,然后再在下一行画四个方块。不断地绘出和擦掉就形成了动画,所以应该要有画和擦方块的代码。然后左右键实现左移和右移,下键实现加速,上键实现旋转,这其实都应该是函数,当然左右移动需要考虑碰撞的问题,下移需要考虑堆积和消层的问题。

如果就用WinForm的方式开发,打算怎么开发呢?

那当然是先建立一个窗体Form,然后加一个用于游戏框的控件,比如Panel或者PictureBox,一个按钮Button来控制‘开始’,最后再放一个Timer控件用于分时动画的编程。写代码当然就是编写Timer_Tick事件来绘出和擦除方块,并做出堆积和消层的判断。再编写控件的键盘事件,按了左箭头则左移,右箭头则右移等等。对了,还需要用到些GDI+技术的方法来画方块和擦方块。

你能不能就这些代码划分一下类呢?

分类?这里好像关键在于各种事件代码如何写吧,这里有什么类可言呢?

看来你的面向过程开发已经根深蒂固了。你把所有的代码都写在了Form1.cs这个类里,你觉得这合理吗?

打个比方,如果现在要你写的是手机版的俄罗斯方块程序,即Pocket PC或者Windows CE上运行的程序,它们可以安装.NET框架的精简版,运行C#语言编写的应用程序,但PC上的普通WinForm界面的程序不能使用。那你现在这个代码有什么可以复用的吗?

这当中,有些东西是始终没变的。

下落、旋转、碰撞判断、移动、堆积这些都是和游戏有关的逻辑,和界面如何表示没有什么关系,为什么要写在一个类里面呢?如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当变化发生时,设计会遭受到意想不到的破坏。事实上,你完全可以找出哪些是界面,哪些是游戏逻辑,然后进行分离。

方块的可移动的游戏区域,可以设计为一个二维整型数组用来表示坐标,宽10,高20,比如‘int[,] arraySquare=new int[10,20];’,那么整个方块的移动其实就是数组的下标变化,比如原方块在arraySquare [3,5]上,则下移时变成arraySquare [3,6],如果下移同时还按了左键,则是arraySquare [2,6]。每个数组的值就是是否存在方块的标志,存在为1,不存在时缺省为0。这下你该明白,所谓的碰撞判断,是否能左移,就是判断arraySquare[x,y]中的x–1是否小于0,否则就撞墙了。或者arraySquare[x–1,y]是否等于1,否则就说明左侧有堆积的方块。所谓堆积,不过是判断arraySquare[x,y+1]是否等于1的过程,如果是,则将自己arraySquare [x,y]的值改1。那么消层,其实就是arraySquare [x,y]中循环x由0到9,判断arraySquare [x,y]是否都等于1,是则此行数据清零,并将其上方的数组值遍历下移一位。”

所谓游戏逻辑,不过就是数组的每一项值变化的问题,下落、旋转、碰撞判断、移动、堆积这些都是在做数组具体项的值的变化。而界面表示逻辑,不过是根据数组的数据进行绘出和擦除,或者根据键盘命令调用数组的相应方法进行改变。因此,至少应该考虑将此程序分为两个类,一个是游戏逻辑的类,一个是WinForm窗体的类。当有一天要改变界面,或者换界面时,不过是窗体类的变化,和游戏逻辑无关,以此达到复用的目的。

软件设计真正要做的许多内容,就是发现职责并把那些职责相互分离。其实要去判断是否应该分离出类来,也不难,那就是如果你能够想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责,就应该考虑类的职责分离。

界面的变化是和游戏本身没有关系的,界面是容易变化的,而游戏逻辑是不太容易变化的,将它们分离开有利于界面的改动。

编程时,要在类的职责分离上多思考,做到单一职责,这样代码才是真正的易维护、易扩展、易复用、灵活多样。

开放-封闭原则

在软件设计模式中,不能修改,但可以扩展的思想是最重要的一种设计原则,它就是开放-封闭原则(The Open-Closeed Principle,简称OCP)或叫开-闭原则。”

这个原则其实是有两个特征,一个是说‘对于扩展是开放的(Open for extension)’,另一个是说‘对于更改是封闭的(Closed for modification)’。

在做任何系统的时候,都不要指望系统一开始时需求确定,就再也不会变化,这是不现实也不科学的想法,而既然需求是一定会变化的,那么如何在面对需求的变化时,设计的软件可以相对容易修改,不至于说,新需求一来,就要把整个程序推倒重来。怎样的设计才能面对需求的改变却可以保持相对稳定,从而使得系统可以在第一个版本以后不断推出新的版本呢?,开放-封闭给我们答案。

设计软件要容易维护又不容易出问题的最好的办法,就是多扩展,少修改?

何时应对变化

开放-封闭原则的意思是说,你设计的时候,时刻要考虑,尽量让这个类是足够好,写好了就不要去修改了,如果新需求来,我们增加一些类就完事了,原来的代码能不动则不动。

绝对的对修改关闭是不可能的。无论模块是多么的‘封闭’,都会存在一些无法对之封闭的变化。既然不可能完全封闭,设计人员必须对于他设计的模块应该对哪种变化封闭做出选择。他必须先猜测出最有可能发生的变化种类,然后构造抽象来隔离那些变化。

我们很难预先猜测,但我们却可以在发生小变化时,就及早去想办法应对发生更大变化的可能。也就是说,等到变化发生时立即采取行动。正所谓,同一地方,摔第一跤不是你的错,再次在此摔跤就是你的不对了。

在我们最初编写代码时,假设变化不会发生。当变化发生时,我们就创建抽象来隔离以后发生的同类变化[ASD]。比如,我之前让你写的加法程序,你很快在一个client类中就完成,此时变化还没有发生。然后我让你加一个减法功能,你发现,增加功能需要修改原来这个类,这就违背了今天讲到的‘开放-封闭原则’,于是你就该考虑重构程序,增加一个抽象的运算类,通过一些面向对象的手段,如继承,多态等来隔离具体加法、减法与client耦合,需求依然可以满足,还能应对变化。这时我又要你再加乘除法功能,你就不需要再去更改client以及加法减法的类了,而是增加乘法和除法子类就可。即面对需求,对程序的改动是通过增加新代码进行的,而不是更改现有的代码[ASD]。这就是‘开放-封闭原则’的精神所在。(样例代码见第1章)

当然,并不是什么时候应对变化都是容易的。我们希望的是在开发工作展开不久就知道可能发生的变化。查明可能发生的变化所等待的时间越长,要创建正确的抽象就越困难。”

如果加减运算都在很多地方应用了,再考虑抽象、考虑分离,就很困难。

开放-封闭原则是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处,也就是可维护、可扩展、可复用、灵活性好。开发人员应该仅对程序中呈现出频繁变化的那些部分做出抽象,然而,对于应用程序中的每个部分都刻意地进行抽象同样不是一个好主意。拒绝不成熟的抽象和抽象本身一样重要。切记,切记。

我还以为尽量地抽象是好事呢,看来过犹不及呀。

依赖倒转原则

依赖倒转和依赖注入,控制反转是不一样的东西。

面向对象的四个好处是:

  • 可维护
  • 可扩展
  • 可复用
  • 灵活性好

电脑好修而收音机不好修的原因是电脑是模块化的,而收音机不是,各组件紧密结合。

可以把PC电脑理解成是大的软件系统,任何部件如CPU、内存、硬盘、显卡等都可以理解为程序中封装的类或程序集,由于PC易插拔的方式,那么不管哪一个出问题,都可以在不影响别的部件的前提下进行修改或替换。

面向对象里把这种关系叫强内聚、松耦合吧,即高内聚,低耦合。

面向对象的几大设计原则

单一职责原则,对象各自的职责是明确的。
开放-封闭原则,对扩展开放,对修改关闭

依赖倒转原则,原话解释是抽象不应该依赖细节,细节应该依赖于抽象,就是要针对接口类编程,不要对实现类编程。通过接口声明对象,通过具体对象来调用。

依赖倒转原则

A.高层模块不应该依赖低层模块。两个都应该依赖抽象。
B.抽象不应该依赖细节。细节应该依赖抽象。

为什么要叫倒转呢?

面向过程的开发时,为了使得常用代码可以复用,一般都会把这些常用代码写成许许多多函数的程序库,这样我们在做新项目时,去调用这些低层的函数就可以了。比如我们做的项目大多要访问数据库,所以我们就把访问数据库的代码写成了函数,每次做新项目时就去调用这些函数。这也就叫做高层模块依赖低层模块。

我们要做新项目时,发现业务逻辑的高层模块都是一样的,但客户却希望使用不同的数据库或存储信息方式,这时就出现麻烦了。我们希望能再次利用这些高层模块,但高层模块都是与低层的访问数据库绑定在一起的,没办法复用这些高层模块,这就非常糟糕了。就像刚才说的,PC里如果CPU、内存、硬盘都需要依赖具体的主板,主板一坏,所有的部件就都没用了,这显然不合理。反过来,如果内存坏了,也不应该造成其他部件不能用才对。而如果不管高层模块还是低层模块,它们都依赖于抽象,具体一点就是接口或抽象类,只要接口是稳定的,那么任何一个的更改都不用担心其他受到影响,这就使得无论高层模块还是低层模块都可以很容易地被复用。这才是最好的办法。

为什么依赖了抽象的接口或抽象类,就不怕更改呢?

原因就是里氏代换原则。

里氏替换原则

里氏代换原则是在1988年发表的,它的白话翻译就是一个软件实体如果使用的是一个父类的话,那么一定适用于其子类,而且它察觉不出父类对象和子类对象的区别。也就是说,在软件里面,把父类都替换成它的子类,程序的行为没有变化,简单地说,子类型必须能够替换掉它们的父类型[ASD].

里氏代换原则

1
子类型必须能够替换掉它们的父类型。

子类继承了父类,所以子类可以以父类的身份出现。如果在面向对象设计时,一个是鸟类,一个是企鹅类,如果鸟是可以飞的,企鹅不会飞,那么企鹅是鸟吗?企鹅可以继承鸟这个类吗”

企鹅是一种特殊的鸟,尽管不能飞,但它也是鸟呀,当然可以继承。

子类拥有父类所有非private的行为和属性。鸟会飞,而企鹅不会飞。尽管在生物学分类上,企鹅是一种鸟,但在编程世界里,企鹅不能以父类—鸟的身份出现,因为前提说所有鸟都能飞,而企鹅飞不了,所以,企鹅不能继承鸟类。

也正因为有了这个原则,使得继承复用成为了可能,只有当子类可以替换掉父类,软件单位的功能不受到影响时,父类才能真正被复用,而子类也能够在父类的基础上增加新的行为。比方说,猫是线承动物类的,以动物的身份拥有吃、喝、跑、叫等行为,可当某一天,我们需要狗、牛、羊也拥有类似的行为,由于它们都是继承于动物,所以除了更改实例化的地方,程序其他处不需要改变。

收音机就是典型的耦合过度,只要收音机出故障,不管是没有声音、不能调频,还是有杂音,反正都很难修理,不懂的人根本没法修,因为任何问题都可能涉及其他部件,各个部件相互依赖,难以维护。非常复杂的PC电脑可以修,反而相对简单的收音机不能修,这其实就说明了很大的问题。当然,电脑的所谓修也就是更换配件, CPU或内存要是坏了,老百姓是没法修的。现在在软件世界里,收音机式的强耦合开发还是太多了,比如前段时间某银行出问题,需要服务器停机大半天的排查修整,这要损失多少钱。如果完全面向对象的设计,或许问题的查找和修改就容易得多。依赖倒转其实可以说是面向对象设计的标志,用哪种语言来编写程序不重要,如果编写时考虑的都是如何针对抽象编程而不是针对细节编程,即程序中所有的依赖关系都是终止于抽象类或者接口,那就是面向对象的设计,反之那就是过程化的设计了。

用子类对象实例化父类对象

注意:

只能调用到子类对象里面的父类对象

如果父类成员方法被子类重写了,那么就调用子类里面重写的方法

里氏替换原则有三种表现形式:

  • 1、用子类对象实例化父类对象。
  • 2、父类作为参数,传入子类对象。
  • 3、父类作为返回值,可以返回子类对象。

使用场景:

游戏中的宠物商店

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static Animal GetAnimal(string name)
{
if (name == "Cat")
{
return new Cat();
}
else if (name == "Dog")
{
return new Dog();
}
else
{
return null;
}
}

单例模式两个作用
1、主要是让内存中只有一个对象,保证数据的正确性。
2、节省内存

自己的总结

并不是写一个继承就能自动满足里氏替换原则的。比如鸟类,写了一个会飞的方法,然后鸵鸟继承了这个类,某处调用了父类鸟飞的方法,然后就满足不了里氏替换原则了。

不能用鸵鸟替换掉鸟类,因为鸵鸟不会飞,所以这个父类设计错误。

再比如设计一个宠物类,宠物的属性有重量、价格、颜色,返回值是宠物类,然后根据传参返回具体的宠物。然后直接使用宠物类Pets.climbTree()肯定不行,因为狗不会上树。

所以设计父类的时候一定要拿到所有的共同点。

迪米特法则

哪怕两个人,也应该有管理才好。

迪米特法则(LoD)也叫最少知识原则。

迪米特法则首先强调的前提是在类的结构设计上,每一个类都应当尽量降低成员的访问权限,也就是说,一个类包装好自己的private状态,不需要让别的类知道的字段或行为就不要公开。

需要公开的字段,通常就用属性来体现了。这就是封装的思想。

面向对象的设计原则和面向对象的三大特性本就不是矛盾的。迪米特法则其根本思想,是强调了类之间的松耦合。

我们在程序设计时,类之间的耦合越弱,越有利于复用,一个处在弱耦合的类被修改,不会对有关系的类造成波及。也就是说,信息的隐藏促进了软件的复用。

0%